import { SAML, ValidateInResponseTo } from "@node-saml/node-saml"; import { getIDPMetadata, normalizeCertificate, } from "@/lib/saml/idp-metadata"; import { getSPMetadata, } from "@/lib/saml/sp-metadata"; import { debugLog, debugError, debugSuccess, debugProcess, debugMock } from '@/lib/debug-utils'; export interface SAMLProfile { nameID?: string; nameIDFormat?: string; attributes?: Record; // 문자열 또는 배열 모두 지원 [key: string]: unknown; } export interface SAMLUser { id: string; email: string; name: string; companyId?: number; techCompanyId?: number; domain?: string; } // SAML 설정 생성 (sync 함수) - 환경변수 기반으로 변경했음 export function createSAMLConfig() { console.log("⚙️ Creating SAML configuration..."); try { const idpMetadata = getIDPMetadata(); const spMetadata = getSPMetadata(); console.log("📋 IdP Metadata loaded:", { entityId: idpMetadata.entityId, ssoUrl: idpMetadata.ssoUrl, organization: idpMetadata.organization, wantAuthnRequestsSigned: idpMetadata.wantAuthnRequestsSigned, }); console.log("📋 SP Metadata loaded:", { entityId: spMetadata.entityId, callbackUrl: spMetadata.callbackUrl, authnRequestsSigned: spMetadata.authnRequestsSigned, }); const config = { callbackUrl: spMetadata.callbackUrl, // IDP 메타데이터 기반 설정 entryPoint: idpMetadata.ssoUrl, // SP Entity ID issuer: spMetadata.entityId, // IDP 인증서 (정규화된 PEM 형식) idpCert: normalizeCertificate(idpMetadata.certificate), privateKey: process.env.SAML_SP_PRIVATE_KEY, // IdP에서 요구하는 설정 identifierFormat: idpMetadata.nameIdFormat, signatureAlgorithm: "sha256" as const, digestAlgorithm: "sha256", // SP 메타데이터 설정 decryptionPvk: process.env.SAML_SP_PRIVATE_KEY, publicCert: process.env.SAML_SP_CERT, // IdP 메타데이터 기반 설정 wantAuthnResponseSigned: idpMetadata.wantAuthnRequestsSigned, wantAssertionsSigned: spMetadata.wantAssertionsSigned, validateInResponseTo: ValidateInResponseTo.never, disableRequestedAuthnContext: true, // HTTP-Redirect 바인딩 설정 authnRequestBinding: undefined, // HTTP-Redirect (GET) 사용 (기본값) skipRequestCompression: false, // Deflate 압축 사용 // 추가 보안 설정 acceptedClockSkewMs: 5000, // 5초 클럭 차이 허용 forceAuthn: false, // IDP Entity ID 설정 idpIssuer: idpMetadata.entityId, }; console.log("✅ SAML Config created:", { callbackUrl: config.callbackUrl, entryPoint: config.entryPoint, issuer: config.issuer, idpIssuer: config.idpIssuer, identifierFormat: config.identifierFormat, hasIdpCert: !!config.idpCert, hasPrivateKey: !!config.privateKey, hasPublicCert: !!config.publicCert, wantAuthnResponseSigned: config.wantAuthnResponseSigned, wantAssertionsSigned: config.wantAssertionsSigned, }); return config; } catch (error) { console.error("💥 Failed to create SAML Config:", error); throw error; } } // SAML AuthnRequest 생성 (서버 액션) export async function createAuthnRequest(relayState?: string): Promise { "use server"; console.log("SSO STEP 2: Create AuthnRequest", { relayState }); // Mock IdP 모드 체크 if (process.env.SAML_MOCKING_IDP === 'true') { debugMock("Mock IdP mode enabled - simulating SAML response"); return createMockSAMLFlow(relayState); } try { const config = createSAMLConfig(); console.log("SAML Config ready for AuthnRequest generation"); const saml = new SAML(config); console.log("SAML instance created, generating authorize URL..."); const startTime = Date.now(); const authorizeUrl = await saml.getAuthorizeUrlAsync( relayState || "", // RelayState - 원래 가려던 페이지 undefined, // host { additionalParams: {}, // additionalAuthorizeParams: {}, } ); const endTime = Date.now(); // 🔍 SAML AuthnRequest 디코딩 및 분석 try { const urlObj = new URL(authorizeUrl); const samlRequest = urlObj.searchParams.get("SAMLRequest"); if (samlRequest) { console.log("SAML AuthnRequest 분석:"); console.log("1️⃣ 원본 URL:", authorizeUrl); console.log( "2️⃣ URL 디코딩된 SAMLRequest:", decodeURIComponent(samlRequest) ); try { // Base64 디코딩 const base64DecodedBuffer = Buffer.from( decodeURIComponent(samlRequest), "base64" ); const base64DecodedString = base64DecodedBuffer.toString("utf-8"); // XML인지 확인 (XML은 '<'로 시작함) if (base64DecodedString.trim().startsWith("<")) { console.log("Base64 디코딩된 XML (압축 없음):"); console.log("───────────────────────────────────"); console.log(base64DecodedString); console.log("───────────────────────────────────"); // XML 구조 분석 const xmlLines = base64DecodedString .split("\n") .filter((line) => line.trim()); console.log("XML 구조 요약:"); xmlLines.forEach((line, index) => { const trimmed = line.trim(); if ( trimmed.includes(" line.trim()); console.log("XML 구조 요약:"); xmlLines.forEach((line: string, index: number) => { const trimmed = line.trim(); if ( trimmed.includes("") || trimmed.includes("AssertionConsumerServiceURL=") ) { console.log(` ${index + 1}: ${trimmed}`); } }); // 중요한 정보 추출 const idMatch = decompressed.match(/ID="([^"]+)"/); const destinationMatch = decompressed.match( /Destination="([^"]+)"/ ); const issuerMatch = decompressed.match( /]*>([^<]+)<\/saml:Issuer>/ ); const acsMatch = decompressed.match( /AssertionConsumerServiceURL="([^"]+)"/ ); console.log("추출된 핵심 정보:"); console.log(` Request ID: ${idMatch ? idMatch[1] : "없음"}`); console.log( ` Destination: ${ destinationMatch ? destinationMatch[1] : "없음" }` ); console.log( ` Issuer: ${issuerMatch ? issuerMatch[1] : "없음"}` ); console.log( ` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}` ); } catch (inflateError) { console.log("❌ Deflate 압축 해제 실패:", (inflateError as Error).message); console.log( " 원본 바이너리 데이터 (hex):", base64DecodedBuffer.toString("hex").substring(0, 100) + "..." ); } } } catch (decodeError) { console.log("❌ Base64 디코딩 실패:", (decodeError as Error).message); } } } catch (analysisError) { console.log("⚠️ SAML AuthnRequest 분석 중 오류:", (analysisError as Error).message); } console.log("✅ SAML AuthnRequest URL generated:", { url: authorizeUrl.substring(0, 100) + "...", fullUrlLength: authorizeUrl.length, processingTime: `${endTime - startTime}ms`, timestamp: new Date().toISOString(), }); return authorizeUrl; } catch (error) { console.error("💥 Failed to create SAML AuthnRequest:", { error: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error ? error.stack : undefined, timestamp: new Date().toISOString(), }); throw error; } } // SAML Response 검증 및 파싱 (서버 액션) export async function validateSAMLResponse( samlResponse: string ): Promise { "use server"; console.log("🔍 Starting SAML Response validation..."); console.log("📊 SAML Response info:", { responseLength: samlResponse.length, firstChars: samlResponse.substring(0, 50) + "...", isBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(samlResponse), timestamp: new Date().toISOString(), }); // Mock IdP 모드 체크 if (process.env.SAML_MOCKING_IDP === 'true') { debugMock("Mock IdP mode - returning mock SAML profile"); return createMockSAMLProfile(samlResponse); } // 실제 SAML 검증 수행 (기본값) console.log( "🔐 Using Real SAML validation (SAML_MOCKING_IDP=false or not set)" ); try { console.log("⚙️ Creating SAML instance for validation..."); const saml = new SAML(createSAMLConfig()); console.log("✅ SAML instance created, starting validation..."); const startTime = Date.now(); const result = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse, }); const endTime = Date.now(); // node-saml 라이브러리는 { profile, loggedOut } 형태로 반환 const profile = result.profile; if (!profile) { throw new Error("No profile returned from SAML validation"); } // SAMLProfile 형태로 변환 (타입 안전성 확보) const samlProfile: SAMLProfile = { nameID: profile.nameID as string | undefined, nameIDFormat: profile.nameIDFormat as string | undefined, attributes: profile.attributes as Record | undefined, }; console.log("✅ Real SAML Profile validated successfully:", { nameID: samlProfile.nameID, nameIDFormat: samlProfile.nameIDFormat, attributeCount: Object.keys(samlProfile.attributes || {}).length, attributes: Object.keys(samlProfile.attributes || {}), processingTime: `${endTime - startTime}ms`, timestamp: new Date().toISOString(), }); return samlProfile; } catch (error) { console.error("❌ Real SAML validation error:", { error: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error ? error.stack : undefined, samlResponseLength: samlResponse.length, timestamp: new Date().toISOString(), }); throw new Error( `SAML validation failed: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } // SAML Profile을 User 객체로 변환 (sync 함수) export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser { console.log("🔄 Mapping SAML profile to user:", { nameID: profile.nameID, attributes: profile.attributes, }); // SAML attributes는 문자열 또는 배열 형태일 수 있음 const extractAttributeValue = (key: string): string | undefined => { const value = profile.attributes?.[key]; if (Array.isArray(value)) { return value.length > 0 ? value[0] : undefined; } return typeof value === 'string' ? value : undefined; }; // 기본적으로 nameID를 사용하거나 attributes에서 추출 const id = profile.nameID || extractAttributeValue('id') || extractAttributeValue('sub'); const email = extractAttributeValue('email') || extractAttributeValue('emailAddress'); const name = extractAttributeValue('name') || extractAttributeValue('displayName') || extractAttributeValue('cn'); // 필수 필드 검증 if (!id) { throw new Error('SAML profile missing required field: id (nameID)'); } if (!email) { throw new Error('SAML profile missing required field: email'); } if (!name) { throw new Error('SAML profile missing required field: name'); } // UTF-8 문자열 정규화 및 검증 const normalizedName = name.normalize("NFC").trim(); // 한글이 깨진 경우 감지 및 로그 const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(normalizedName); if (hasInvalidChars) { console.warn("⚠️ Invalid UTF-8 characters detected in name:", { originalName: name, normalizedName, charCodes: [...normalizedName].map((c) => c.charCodeAt(0)), hexDump: [...normalizedName] .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0")) .join(""), }); } // 회사 정보는 SSO 로그인 시 없음 (evcp 도메인) const companyId = undefined; const techCompanyId = undefined; const domain = 'evcp'; const user: SAMLUser = { id, email, name: normalizedName, companyId, techCompanyId, domain, }; console.log("👤 Mapped user object:", JSON.stringify(user)); return user; } // Mock SAML 플로우 생성 (테스트용) function createMockSAMLFlow(relayState?: string): string { debugMock("Creating mock SAML flow...", { relayState }); // Mock 모드에서는 Mock IdP 엔드포인트로 리다이렉션 const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; let mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`; // RelayState가 있으면 URL 파라미터로 전달 if (relayState) { mockIdpUrl += `?RelayState=${encodeURIComponent(relayState)}`; } debugMock("Mock SAML Flow - redirecting to Mock IdP:", mockIdpUrl); return mockIdpUrl; } // Mock SAML Profile 생성 (테스트용) function createMockSAMLProfile(samlResponse: string): SAMLProfile { console.log("🎭 Creating mock SAML profile from response..."); try { // SAML Response가 우리가 생성한 Mock인지 확인 const decodedXML = Buffer.from(samlResponse, 'base64').toString('utf-8'); const isMockResponse = decodedXML.includes('MockIdP'); if (!isMockResponse) { console.warn("⚠️ Mock mode enabled but received non-mock SAML Response"); } console.log("🎭 Mock SAML XML preview:", decodedXML.substring(0, 200) + "..."); } catch (error) { console.warn("⚠️ Could not decode SAML Response for mock analysis:", (error as Error).message); } // Mock SAML Profile 반환 (실제 SAML Response와 일치하도록 문자열 형태) const mockProfile: SAMLProfile = { nameID: "testuser@samsung.com", nameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress", attributes: { email: "testuser@samsung.com", name: "테스트 사용자", displayName: "Test User Samsung", // 추가 테스트 속성들 department: "개발팀", employeeId: "TEST001", mobile: "010-1234-5678" } }; console.log("🎭 Mock SAML Profile created:", { nameID: mockProfile.nameID, nameIDFormat: mockProfile.nameIDFormat, attributeCount: Object.keys(mockProfile.attributes || {}).length, attributes: Object.keys(mockProfile.attributes || {}), timestamp: new Date().toISOString(), }); return mockProfile; } // SAML 로그아웃 URL 생성 (서버 액션) // 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠. export async function createLogoutRequest(nameID: string): Promise { "use server"; const saml = new SAML(createSAMLConfig()); // Profile 객체 형태로 전달 const profile = { nameID }; return await saml.getLogoutUrlAsync( profile, "", // RelayState { nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", } ); }